Z Algorithm

쉽게 설명하는 알고리즘 시리즈, 대망의 1편입니다.

계속 연재할지 중간에 슬쩍 사라질지는 저도 모르겠지만, 아무튼 열심히 해 보겠습니다.

다양한 알고리즘들을 직접 공부하고, 하나씩 소개하는 방식으로 진행됩니다.

첫 번째 알고리즘은, Z 알고리즘입니다.

Z 알고리즘이란?

Z Array

Z 알고리즘이란 문자열에서 특정 패턴을 찾는 패턴 검색 알고리즘입니다. O(M + N)의 선형 시간 복잡도를 가집니다.

또한 Z 알고리즘은 현재 인덱스, 즉 String[i]에서 시작하는 가장 긴 접두사 부분 문자열의 길이를 저장하는 배열인 *Z 배열이라는 보조 배열과 함께 작동합니다.

  • 접두사 부분 문자열: 문자열의 첫 부분과 일치하는 현재 인덱스부터 시작하는 부분 문자열
Index            0   1   2   3   4   5   6 
Text             a   a   b   c   a   a   b
Z values         N   1   0   0   3   1   0

예를 들어, “aabcaab”라는 문자열에서, 5번째 a의 가장 긴 접두사 부분 문자열은 aab입니다. aabcaab가 시작 부분과 일치하기 때문입니다.

그렇기 때문에 만약 Z[i] = k라면, String[i .. i + k] = String[0 .. k]가 성립합니다. Z 알고리즘은 이 원리를 이용해 선형 시간으로 패턴을 탐색합니다.

쉽게 생각하자면, 패턴과 텍스트를 연결하여 “P$T” 문자열을 생성한다고 생각하면 됩니다. 여기서 P와 T는 접두사, $는 접두사에 포함되지 않는 문자입니다.

만약 Z 배열에서 Z[i]가 임의의 지점에서 접두사 길이와 같으면, 위의 원리에 따라 해당 지점은 접두사 부분 문자열이 존재한다고 볼 수 있습니다.

개념은 이 정도로 하고, 실제 코드를 보며 이해해 봅시다.

구현

fun getZArray(s: String): IntArray {
    val n = s.length
    
    // 문자열 크기의 Z 배열을 사용합니다.
    val zArray = IntArray(n)
    
    // 선형 시간으로 구성하기 위해 두 개의 포인터 Left와 Right로 접두사 부분 문자열의 간격을 나타냅니다.
    var left = 0
    var right = 0
   
    // 0번째 인덱스는 문자열 전체가 접두사이기 때문에 탐색은 1부터 시작합니다.
    for (i in 1 until n) {
    
        // i가 Right 포인터보다 크다면, i는 두 포인터 사이의 간격(즉, 접두사 부분 문자열) 밖에 있습니다.
        // 그렇기 때문에 i 이전에 시작하고 i 이후에 끝나는 접두사 부분 문자열이 없습니다. 즉, 현재의 두 포인터 사이의 간격에서 얻을 수 있는 정보가 없습니다.
        if (i > right) {
        
            // 현재의 포인터 값들은 쓸모가 없기 때문에, Left와 Right를 i로 초기화합니다.
            left = i
            right = i

            // i만큼의 간격을 유지하며 Left와 Right가 가리키는 값이 다를 때까지 (즉, 더 이상 접두사 부분 문자열이 아닐 때까지) 간격을 확장합니다.
            while (right < n && s[right - left] == s[right]) {
                right++
            }

            // Z[i]를 두 포인터 사이의 간격(접두사 부분 문자열의 길이)으로 설정합니다.
            zArray[i] = right-- - left
        } else {
            // i가 Right 포인터보다 작거나 같다면, 여기부터는 두 가지 경우가 있습니다.
            val k = i - left

            // 만약 k부터 시작하는 접두사 부분 문자열의 길이가 현재 간격보다 짧다면, 확장할 수 있는 최대 간격이 현재 간격보다 짧다는 것을 의미합니다.
            // 따라서 i로부터 시작하는 접두사 부분 문자열의 길이는 최대 k이기 때문에  Z[i] = Z[k]로 설정하고, 간격은 동일하게 유지한 채로 다음 반복으로 넘어갑니다.
            if (zArray[k] < right - i + 1) {
                zArray[i] = zArray[k]
            } else {
            // 현재 간격보다 크다면, 현재 간격을 더 확장할 수 있다는 것을 의미합니다. 따라서 위의 코드처럼 간격을 확장하고 Z[i]를 설정합니다.
                left = i

                while (right < n && s[right - left] == s[right]) {
                    right++
                }

                zArray[i] = right-- - left
            }
        }
    }

    return zArray
}

코드 예제는 GitHub에서 확인하실 수 있습니다.

연습 문제

문자열의 점수 합계

모든 공통 접두사의 길이의 합을 구하는 문제입니다. Hard 난이도지만 Z 알고리즘으로 매우 쉽게 해결할 수 있습니다.